# -*- coding: utf-8 -*-
"""
    sourcechangenotification
    ~~~~~~~~

    Source change notification handlers.

    :copyright: 2010 by Baptiste Lepilleur.
    :license: Apache License 2.0, see LICENSE.txt for more details.
"""
from Cheetah.NameMapper import NotFound
from tipfy import RequestHandler, Response, request, Unauthorized, NotFound, url_for
from tipfy.ext.mail import InboundMailHandler
from tipfy.ext.db import simplejson
#from django.utils import simplejson
import logging
from google.appengine.ext import db
from google.appengine.ext import deferred
from google.appengine.api.labs import taskqueue
import projectmanager

GOOGLECODE_KIND = 'googlecode'
SVNNOTIFY_KIND = 'svnnotify'
CONFIG_KINDS = (GOOGLECODE_KIND, SVNNOTIFY_KIND)

class SourceChangeConfig(db.Model):
    """A Project source change configuration.
    Key is project slug_name.
    """
    kind = db.StringProperty()
    detail = db.JsonProperty(required=False)

class SourceChange(db.Model):
    """Version control system change notification."""
    project_id = db.IntegerProperty(required=True) # project.key().id()
    revision = db.StringProperty()
    author = db.StringProperty()
    timestamp = db.IntegerProperty()
    message = db.TextProperty(required=False)
    nb_paths = db.IntegerProperty(required=False, default=0)
    # Dict containing list of modified paths. May be partial if
    # too many paths have been modified.
    # { "added": [paths], "modified": [paths], "removed": [paths] }
    detail = db.TextProperty(required=False) # JSON encoded change details

class PendingMail(db.Model):
    """A mail that was not recognize as a commit notification.
    """
    sender = db.StringProperty()
    to = db.StringProperty()
    cc = db.StringProperty()
    bcc = db.StringProperty()
    reply_to = db.StringProperty()
    subject = db.StringProperty()
    body = db.TextProperty()
    date = db.StringProperty()
    comment = db.TextProperty()

    project_slug_name = db.StringProperty() # Parts of 'to' following the last '.'
    created = db.DateTimeProperty(verbose_name='Created',
                                  auto_now_add=True)

def get_config_by_slug_name( slug_name ):
    return SourceChangeConfig.get_by_key_name( slug_name )

def handle_new_source_change( notification, continuation=None ):
    """Injects a repository commit notification in the system.
    Parameters:
        notification: a dictionnary describing the commit with the
        following structure:
            'project_slug': String, # Project slug_name.
            'revision': String, # Repository identifier for this commit.
            'author': String, # Username responsible for commit.
            'timestamp': float, # Repository commit timestamp.
            'message': String, # Commit log message.
            'path_count': int, # Total number of paths modified in this revision.
            'added': [String], # List of added paths (may be truncated)
            'modified': [String], # List of added paths (may be truncated)
            'removed': [String], # List of added paths (may be truncated)
        continuation: None or tuple (callable, *args, **kwargs), to be added
            to the task queue in the transaction

    Structure derived from:
    http://code.google.com/p/support/wiki/PostCommitWebHooks
    """
    project_id = projectmanager.slug_name_to_id(
        notification['project_slug'] )
    if project_id is None:
        raise NotFound( 'Datastore inconsistency: no project project "%s"' %
            notification['project_slug'] )
    key_name = '%d:%s' % (project_id, notification['revision'])
    detail = {}
    if notification['added']:
        detail['added'] = notification['added']
    if notification['modified']:
        detail['modified'] = notification['modified']
    if notification['removed']:
        detail['removed'] = notification['removed']
    json_detail = simplejson.dumps( detail )
    def tnx():
        source_change = SourceChange.get_by_key_name(key_name)
        if not source_change:
            source_change = SourceChange( key_name=key_name,
                project_id=project_id,
                revision=notification['revision'],
                author=notification['author'],
                timestamp=notification['timestamp'],
                message=notification['message'],
                nb_paths=notification['nb_paths'],
                detail=json_detail
                )
            source_change.put()
            taskqueue.add( url_for('scheduler_on_source_change',
                source_change_key_name=key_name) )
        else:
            # Do nothing: webhook invoked twice or duplicate mail...
            logging.warning( 'SourceChange: duplicate for %s,  revision=%s' %
                project_slug, notification['revision'] )
        deferred.defer(continuation[0], *continuation[1], **continuation[2])
        if continuation:
            deferred.defer(continuation[0], *continuation[1], **continuation[2])
    db.run_in_transaction(tnx)

def _map_googlecode_revision( commit ):
    return {
        'project_slug': project_slug,
        'revision': commit['revision'],
        'author': commit['author'],
        'timestamp': commit['timestamp'],
        'message': commit['message'],
        'path_count': commit['path_count'],
        'added': list(commit.get('added',[])),
        'modified': list(commit.get('modified',[])),
        'removed': list(commit.get('removed',[])),
    }

def _process_googlecode_revisions( revisions ):
    revisions.sort( key=lambda commit: commit['timestamp'] )
    first_commit = revisions[0]
    if len(revisions) > 1:
        remaining_commits = revisions[1:]
        continuation = (_process_googlecode_revisions, (remaining_commits,), {})
    else:
        continuation = None
    notification = _map_googlecode_revision(first_commit)
    handle_new_source_change( notification, continuation=continuation )

def _get_and_check_config( project_slug, expected_kind ):
    config = get_config_by_slug_name( project_slug )
    if config is None:
        logging.error( 'Failed processing %s for %s' %
            expected_kind, project_slug )
        raise NotFound( 'No project named "%s"' % project_slug )
    if config.kind != expected_kind:
        logging.error( '%s was not configured for this project' % expected_kind )
        raise NotFound( 'No configuration for project named "%s"' % project_slug )
    return config

class GoogleCodeHandler(RequestHandler):
    """WebHook for google code post commits.
    Based on example code from:
    http://code.google.com/p/support/wiki/PostCommitWebHooks
    """
    def post(self, project_slug=None):
        """Cheks HMAC-MD5 notification and converts the notification to
        common webbuildbot source change notification.
        """
        config = _get_and_check_config( project_slug, GOOGLECODE_KIND )
        logging.info( 'Processing GoogleCodeHandler for %s' % project_slug )
        self._check_authentication( project_slug )
        payload = simplejson.loads(request.form.get('body'))
        self._process_notification( project_slug, config, payload)
        return Response()

    def _check_authentication(self):
        """Checks the HMAC-MD5 signature.
        Raise tipfy.Unauthorized exception on failure.
        """
        actual_digest = request.headers.get('Google-Code-Project-Hosting-Hook-Hmac')
        if not actual_digest:
            self._raise_failed_authentication( 'Missing HMAC-MD5' )

        # @todo Loads it from project sync info
        project_secret_key = 'ABCD'  # From Administer/Source tab

        # Computes expected HMAC-MD5 of the body
        hasher = hmac.new(project_secret_key)
        hasher.update(request.form.get('body'))
        expected_digest = hasher.hexdigest()
        if expected_digest != actual_digest:
            self._raise_failed_authentication( 'Wrong HMAC-MD5' )

    def _raise_failed_authentication( self, message ):
        logging.error(message)
        raise Unauthorized( message )

    def _process_notification( self, project_slug, config, payload ):
        repo_path = payload['repository_path']
        if repo_path not in config.detail['repository_paths']:
            logging.debug( 'Ignored GoogleCode notification because repo "%s"'
                'is not accepted by configuration' % repo_path  )
            return
        _process_googlecode_revisions( payload["revisions"] )

class IncomingMailHandler(InboundMailHandler):
    """Handles incomming mail message.
    Currently only process them as SourceForge svnnotify mail.
    """
    def receive(self, mail_message, **kwargs):
        logging.info( "Received a message from: " + message.sender )
        _, encoded_plaintext = message.bodies('text/plain').next()
        plaintext = encoded_plaintext.decode()
        receiver = _kwargs.pop('to_email_address')
        logging.debug( "Subject:" + message.subject )
        #logging.debug( "Message:" + plaintext )
        logging.debug( "Date:" + message.date )
        logging.debug( "Receiver:" + receiver )

        # Store the unhandled mail in db for admin review
        attrs = {}
        for attr in ('sender', 'subject', 'date'):
            attrs[attr] = getattr( message, attr )
        attrs['to'] = receiver
        email_db = PendingMail( **attrs )
        if '.' in receiver:
            email_db.project_slug_name = receiver.split('.', 1)[-1]
        else:
            email_db.project_slug_name = receiver
        email_db.body = plaintext

        try:
            config = _get_and_check_config( project_slug, SVNNOTIFY_KIND )
        except NotFound, e:
            email_db.comment = str(e)
            email_db.put()
            return Response('')

        import svnnotifyparser
        notification = None
        notification = svnnotifyparser.convert( email_db )
        if not notification:
            email_db.comment = "Failed to parse SVN notification"
            email_db.put()
            logging.error( "Failed parsing svnnotify mail: %s" % e )
            return Response('')

        notification['project_slug'] = email_db.project_slug_name
        handle_new_source_change( notification )

        return Response('')
